iT邦幫忙

0

[Day18]檔案歸檔器 GUI(Tkinter)

  • 分享至 

  • xImage
  •  

昨天做了檔案歸檔器,今天把它改成GUI版本!

  • 分類:副檔名(jpg/ png/ pdf/ …)或 日期(YYYY/MM,依最後修改時間)
  • 動作:move(移動)或 copy(複製)
  • Dry-run 預覽(不動檔)+ CSV 明細
  • 進度條、不凍結(背景執行)
  • 目標檔名衝突時自動加序號避免覆蓋

環境
Python 3.x(內建 Tkinter)→ 不用額外安裝套件。

程式碼(存成 file_sorter_gui.py)

# file_sorter_gui.py — Day 18 GUI:依副檔名/日期歸檔(move/copy/試跑/CSV/進度條)
from __future__ import annotations
import threading, shutil, csv, time, os, sys, subprocess
from pathlib import Path
from typing import Iterable, List, Tuple

from tkinter import Tk, StringVar, BooleanVar, IntVar, filedialog, messagebox
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText

# ---------- 共用邏輯 ----------
def iter_files(root: Path, recursive: bool, patterns: List[str] | None) -> Iterable[Path]:
    pats = patterns or ["*"]
    seen = set()
    for pat in pats:
        glob = root.rglob if recursive else root.glob
        for p in glob(pat):
            if p.is_file():
                rp = p.resolve()
                if rp not in seen:
                    seen.add(rp)
                    yield p

def date_folder(p: Path) -> Path:
    st = p.stat()
    y = time.strftime("%Y", time.localtime(st.st_mtime))
    m = time.strftime("%m", time.localtime(st.st_mtime))
    return Path(y) / m  # 例如 2025/09

def ext_folder(p: Path) -> Path:
    ext = p.suffix.lower().lstrip(".") or "_noext"
    return Path(ext)

def unique_path(dst: Path) -> Tuple[Path, str]:
    """避免覆蓋:若存在就自動加 _1, _2...,回傳(路徑, 註記)"""
    if not dst.exists():
        return dst, ""
    base, ext = dst.stem, dst.suffix
    k = 1
    while True:
        alt = dst.with_name(f"{base}_{k}{ext}")
        if not alt.exists():
            return alt, f"rename({k})"
        k += 1

def write_csv(rows: List[dict], out: Path):
    out.parent.mkdir(parents=True, exist_ok=True)
    cols = ["action","src","dst","note"]
    with out.open("w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        for r in rows: w.writerow(r)

def open_folder(path: Path):
    try:
        if sys.platform.startswith("win"):
            os.startfile(str(path))
        elif sys.platform == "darwin":
            subprocess.run(["open", str(path)])
        else:
            subprocess.run(["xdg-open", str(path)])
    except Exception:
        pass

# ---------- GUI App ----------
class SorterApp:
    def __init__(self):
        self.root = Tk()
        self.root.title("檔案歸檔器 (Day 18 GUI)")
        self.root.geometry("820x520")

        # 狀態
        self.total = IntVar(value=0)
        self.done = IntVar(value=0)

        # 參數
        self.src = StringVar()
        self.dst = StringVar()
        self.by = StringVar(value="ext")           # ext / date
        self.mode = StringVar(value="move")        # move / copy
        self.recursive = BooleanVar(value=True)
        self.patterns = StringVar(value="*.pdf, *.jpg, *.png")  # 逗號或空白分隔
        self.apply_flag = BooleanVar(value=False)  # False = dry-run
        self.out_csv = StringVar(value=str(Path("exports/sort_log.csv")))

        self._build_ui()

    # ---- UI ----
    def _build_ui(self):
        pad = {"padx": 8, "pady": 6}

        # 第1行:來源
        row = ttk.Frame(self.root); row.grid(row=0, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="來源資料夾").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.src).grid(row=0, column=1, sticky="we")
        ttk.Button(row, text="選擇…", command=self.pick_src).grid(row=0, column=2)

        # 第2行:目標
        row = ttk.Frame(self.root); row.grid(row=1, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="目標資料夾").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.dst).grid(row=0, column=1, sticky="we")
        ttk.Button(row, text="選擇…", command=self.pick_dst).grid(row=0, column=2)

        # 第3行:分類/動作/遞迴
        row = ttk.Frame(self.root); row.grid(row=2, column=0, sticky="we", **pad)
        ttk.Label(row, text="分類方式").grid(row=0, column=0)
        ttk.Combobox(row, values=["ext","date"], textvariable=self.by, width=8, state="readonly").grid(row=0, column=1, padx=(6,12))
        ttk.Label(row, text="動作").grid(row=0, column=2)
        ttk.Combobox(row, values=["move","copy"], textvariable=self.mode, width=8, state="readonly").grid(row=0, column=3, padx=(6,12))
        ttk.Checkbutton(row, text="包含子資料夾 (recursive)", variable=self.recursive).grid(row=0, column=4)

        # 第4行:過濾
        row = ttk.Frame(self.root); row.grid(row=3, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="過濾 patterns").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.patterns).grid(row=0, column=1, sticky="we")
        ttk.Label(row, text="(逗號或空白分隔,如:*.pdf, *.jpg)").grid(row=0, column=2, sticky="w", padx=(6,0))

        # 第5行:CSV 輸出
        row = ttk.Frame(self.root); row.grid(row=4, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="CSV 輸出").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.out_csv).grid(row=0, column=1, sticky="we")
        ttk.Button(row, text="瀏覽…", command=self.pick_csv).grid(row=0, column=2)

        # 第6行:按鈕
        row = ttk.Frame(self.root); row.grid(row=5, column=0, sticky="w", **pad)
        ttk.Button(row, text="試跑 (Dry-run)", command=lambda: self.start(False)).grid(row=0, column=0, padx=(0,6))
        ttk.Button(row, text="真的執行 (Apply)", command=lambda: self.start(True)).grid(row=0, column=1, padx=(0,6))
        ttk.Button(row, text="打開輸出資料夾", command=self.open_exports).grid(row=0, column=2)

        # 第7行:進度/狀態
        row = ttk.Frame(self.root); row.grid(row=6, column=0, sticky="we", **pad); row.columnconfigure(0, weight=1)
        self.prog = ttk.Progressbar(row, length=700, mode="determinate", maximum=100)
        self.prog.grid(row=0, column=0, sticky="we")
        self.status = ttk.Label(row, text="等待開始…"); self.status.grid(row=1, column=0, sticky="w", pady=(4,0))

        # 第8行:日誌
        row = ttk.Frame(self.root); row.grid(row=7, column=0, sticky="nsew", **pad)
        self.root.rowconfigure(7, weight=1); row.rowconfigure(0, weight=1); row.columnconfigure(0, weight=1)
        self.log = ScrolledText(row, height=10)
        self.log.grid(row=0, column=0, sticky="nsew")

    # ---- 檔案對話盒 ----
    def pick_src(self):
        d = filedialog.askdirectory(title="選擇來源資料夾")
        if d: self.src.set(d)

    def pick_dst(self):
        d = filedialog.askdirectory(title="選擇目標資料夾")
        if d: self.dst.set(d)

    def pick_csv(self):
        f = filedialog.asksaveasfilename(title="選擇 CSV 輸出檔", defaultextension=".csv",
                                         filetypes=[("CSV", "*.csv")], initialfile="sort_log.csv")
        if f: self.out_csv.set(f)

    def open_exports(self):
        out = Path(self.out_csv.get().strip() or "exports/sort_log.csv")
        open_folder(out.parent if out.suffix else Path(out))

    # ---- 工具 ----
    def _post(self, func, *a, **kw):
        self.root.after(0, lambda: func(*a, **kw))

    def _set_status(self, text: str):
        self.status.config(text=text)

    def _append_log(self, text: str):
        self.log.insert("end", text.rstrip() + "\n"); self.log.see("end")

    def _set_progress(self, done: int, total: int):
        pct = 0 if total == 0 else int(done * 100 / total)
        self.prog["value"] = pct
        self.status.config(text=f"處理中:{done}/{total}  ({pct}%)")

    # ---- 執行 ----
    def start(self, apply_flag: bool):
        src = Path(self.src.get().strip())
        dst = Path(self.dst.get().strip())
        if not src.exists():
            messagebox.showerror("錯誤", "來源資料夾不存在"); return
        if not dst.exists():
            try:
                dst.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                messagebox.showerror("錯誤", f"無法建立目標資料夾:\n{e}"); return

        pats = [p.strip() for p in self.patterns.get().replace(",", " ").split() if p.strip()]
        by = self.by.get()
        mode = self.mode.get()
        recursive = self.recursive.get()
        out_csv = Path(self.out_csv.get().strip() or "exports/sort_log.csv")

        # 重置 UI
        self.log.delete("1.0", "end")
        self.prog["value"] = 0
        self._set_status("掃描檔案中…")

        t = threading.Thread(target=self._worker, args=(src, dst, by, mode, recursive, pats, apply_flag, out_csv), daemon=True)
        t.start()

    def _worker(self, src: Path, dst: Path, by: str, mode: str, recursive: bool,
                pats: List[str], apply_flag: bool, out_csv: Path):
        try:
            files = list(iter_files(src, recursive, pats))
            total = len(files)
            self._post(self._set_progress, 0, total)
            if total == 0:
                self._post(self._set_status, "找不到檔案(檢查來源/過濾條件)")
                return

            rows = []
            done = 0
            self._post(self._append_log, f"共 {total} 個檔案;模式={mode},分類={by},{'Apply' if apply_flag else 'Dry-run'}")

            for p in files:
                sub = ext_folder(p) if by == "ext" else date_folder(p)
                out_dir = (dst / sub)
                out_dir.mkdir(parents=True, exist_ok=True)

                target = out_dir / p.name
                final, note = unique_path(target)

                if apply_flag:
                    try:
                        if mode == "move":
                            shutil.move(str(p), str(final))
                        else:
                            shutil.copy2(str(p), str(final))
                        rows.append({"action": mode, "src": str(p), "dst": str(final), "note": note})
                        self._post(self._append_log, f"✔ {mode}: {p}  →  {final}")
                    except Exception as e:
                        rows.append({"action": "error", "src": str(p), "dst": str(final), "note": str(e)})
                        self._post(self._append_log, f"✖ error: {p} → {final}  ({e})")
                else:
                    rows.append({"action": f"plan-{mode}", "src": str(p), "dst": str(final), "note": note})

                done += 1
                if done % 10 == 0 or done == total:
                    self._post(self._set_progress, done, total)

            write_csv(rows, out_csv)
            if apply_flag:
                self._post(self._set_status, f"完成!已處理 {done} 個,明細:{out_csv}")
                self._post(self._append_log, f"完成:已輸出 CSV → {out_csv}")
            else:
                self._post(self._set_status, f"試跑完成(未動檔)→ {out_csv}")
                self._post(self._append_log, f"試跑完成:請檢視 CSV,確認後按 Apply 執行")

        except Exception as e:
            self._post(self._set_status, f"發生錯誤:{e}")
            self._post(self._append_log, f"[Exception] {e}")

    def run(self):
        self.root.mainloop()

if __name__ == "__main__":
    SorterApp().run()

怎麼用(步驟)

  • 執行:python file_sorter_gui.py
  • 挑「來源 / 目標」資料夾
  • 分類選 ext(依副檔名)或 date(YYYY/MM)
  • 動作選 move(移動)或 copy(複製)
  • 過濾填 *.pdf, *.jpg(逗號或空白分隔;留空=全部)
  • 先按 試跑 (Dry-run) 看日誌與輸出的 CSV,OK 再按 真的執行 (Apply)

實作:
https://ithelp.ithome.com.tw/upload/images/20250930/20169368sx40oM9sjk.png
https://ithelp.ithome.com.tw/upload/images/20250930/20169368usszBrN8aQ.png
https://ithelp.ithome.com.tw/upload/images/20250930/20169368im4V9gi9jm.png
小卡關速修

  • 找不到檔案:過濾字串需含萬用字元,如 *.pdf;或留空代表全部。
  • 覆蓋風險:若目標已有同名檔,程式會自動變為 name_1.ext、name_2.ext… 並在 note 記錄 rename(k)。
  • 日期分類依 mtime:需要用建立時間可自行把 date_folder() 換成 st_ctime。
  • CSV 在哪? 預設寫到 exports/sort_log.csv;按「打開輸出資料夾」會直接開啟。

今日小結
完成一個可視化的「檔案歸檔器」:副檔名/日期分類、移動或複製、Dry-run 預覽、CSV 明細、進度條。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言